La música como creación humana siempre ha sido un medio de expresión cultural, en el cual se han manifestado emociones, ideas y pensamientos. En particular, los géneros musicales tienen la cualidad de estar asociados a la identidad de ciertos grupos dentro de la sociedad moderna, es por esto que es interesante analizar qué cualidades definen a un género musical, por ejemplo, en la cantidad de energía que una canción expresa o el ritmo que esta tiene. En base a esto, surge la motivación de conocer diferencias y similitudes entre los diferentes géneros musicales.
A partir de lo anterior, el objetivo principal de este proyecto corresponde a aplicar técnicas de minería de datos sobre una base de datos que contenga características técnicas de las canciones, de tal forma de ver cómo de relacionan éstas con el género y qué relación hay entre géneros. Para esto se trabaja con el dataset de Spotify que dentro de sus atributos contiene: género, artista, nombre de la canción, tempo, energía, duración, entre otras. La elección de éste dataset se debe a la gran cantidad y variedad de canciones que contiene, reconociendo que actualmente Spotify es la plataforma de música más utilizada.
En lo que respecta a las preguntas de investigación se formulan las siguientes: ¿un género musical puede quedar definido sólo por sus características técnicas?, ¿hay géneros que tienen atributos en común?, ¿qué característica(s) técnica(s) es distintiva a cada género? Asimismo, la hipótesis adoptada sobre estas preguntas es que los géneros sí son distinguibles en base a sus propiedades como el tempo o la instrumentación. Incluso, se incluye que para una canción puede haber más de un género. Ejemplo de esto puede ser con la poca distinción entre el Rap y Hip-Hop.
Para validar la hipótesis y responder las preguntas planteadas se expone una exploración de los datos, con la cual se tiene una visión global de cómo estos se distribuyen, pudiendo reconocer atributos poco relevantes y aquellos que necesitan un pre-procesamiento. Luego, se aplican técnicas de minería de datos, en éste caso, se hace clustering sobre las propiedades técnicas de las canciones y clasificación multi-etiqueta de los géneros musicales puesto que como se veran en los datos, una canción puesde tener asociada más de un genero musical.
%matplotlib inline
!pip install pandas
!pip install -U -q PyDrive
!apt-get install python-pydot
!pip install pydotplus
from google.colab import auth
from oauth2client.client import GoogleCredentials
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
from scipy.spatial.distance import cdist
from sklearn.cluster import MiniBatchKMeans, KMeans
from sklearn.datasets.samples_generator import make_blobs
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier
from sklearn.externals.six import StringIO
from sklearn.linear_model import RidgeClassifierCV
from sklearn.metrics import confusion_matrix, f1_score, recall_score, precision_score, accuracy_score
from sklearn.metrics.pairwise import pairwise_distances_argmin
from sklearn.model_selection import train_test_split, cross_validate
from sklearn.neighbors import KNeighborsClassifier, RadiusNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier, ExtraTreeClassifier, export_graphviz
from sklearn.utils.multiclass import unique_labels
from IPython.display import Image
import pydotplus
import matplotlib.pyplot as plt
import numpy as np
import os
import pandas as pd
import random
import re
import seaborn as sns; sns.set(style="ticks", color_codes=True)
import time
import warnings
# Configura librerías
warnings.filterwarnings('always')
# warnings.simplefilter(action='ignore', category=FutureWarning)
Las funciones que se muestran a continuación tienen la finalidad de trabajar de mejor manera los datos, al momento de hacer su exploración y posterior experimentación tanto de clustering como con los distintos clasificadores.
def natural_sort(l):
"""
Ordena un string con lenguaje natural
"""
convert = lambda text: int(text) if text.isdigit() else text.lower()
alphanum_key = lambda key: [ convert(c) for c in re.split('([0-9]+)', key) ]
return sorted(l, key = alphanum_key)
# ------------------------------------------------------------------------------
def crear_matriz_correlacion(data, genre='', title='Matriz de correlación',
titleGenre='Matriz de correlación - Genero {0}', dropDuplicated=False):
"""
Crea la matriz de correlación de un determinado dataset.
"""
# Crea el gráfico
plt.figure(figsize=(10,10), dpi=80)
ax = plt.axes()
# Se obtiene la data, si se pasa un genero como argumento se selecionan los datos
# del mismo genero
if genre != '':
if 'genre' in data.columns:
data = data[data['genre'] == genre]
else:
raise Exception('Columna genre no existe en el dataset propocionado')
# Elimina repetidos
if dropDuplicated:
data = data.drop_duplicates(keep='first')
# Calcula la correlación, la deja triangular
corr = data.corr()
mask = np.zeros_like(corr)
mask[np.triu_indices_from(mask)] = True
# Aplica máscara
with sns.axes_style('white'):
sns.heatmap(corr,
xticklabels=corr.columns.values,
yticklabels=corr.columns.values,
ax=ax,
annot=True,
# cmap="YlGnBu",
linewidths=0.5,
square=True,
mask=mask)
# Modifica el gráfico
if genre == '':
ax.set_title(title, fontsize=20)
else:
ax.set_title(titleGenre.format(genre), fontsize=20)
plt.show()
# ------------------------------------------------------------------------------
def generar_grafico_particion_data(data, col, title, naturalsort=True, doprint=True,
doplot=True, plotlim=0):
"""
Genera la partición de la data por un dato categorico.
"""
if col is not None:
g = data[col].unique()
else:
g = data.unique()
if naturalsort:
g = natural_sort(g)
else:
g.sort()
u = []
# Calculamos el total de
for i in g:
if col is not None:
gi = data[col] == i
else:
gi = data == i
t = gi.sum()
u.append(t)
# Calcula la suma
uplot = []
gplot = []
if plotlim == 0:
uplot = u
gplot = g
else:
ut = np.sum(u)
for i in range(len(g)):
por = u[i]/ut * 100
if por >= plotlim:
uplot.append(u[i])
gplot.append(g[i])
if doprint:
ut = np.sum(u)
for i in range(len(g)):
por = round(u[i]/ut * 100, 1)
print ('{0}{1}\t{2}%'.format(g[i].ljust(20), str(u[i]).ljust(10), por))
print('\nTotal:', ut)
# Grafica
if doplot:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
ax.pie(uplot, labels=gplot, autopct='%d%%', shadow=False, startangle=90)
ax.set_title(title, fontsize=12)
_ = ax.axis('equal')
return {'genre': g, 'total': u}
# ------------------------------------------------------------------------------
def genera_grafico_genero_tramo_popularidad(data, pmin, pmax, doprint=True,
doplot=True, plotlim=0):
"""
Selecciona un tramo de popularidad de la data, luego grafica la distribución
de generos dentro de la misma. pmin y pmax están entre 0 y 100.
"""
dataPopularity = data['popularity'] # La primera columna
dataP = (dataPopularity >= float(pmin)/100) & (dataPopularity <= float(pmax)/100) # Vector mascara
dataD = data[dataP] # Nuevo DF
title = 'Tramo popularidad {0}-{1}\n'.format(pmin, pmax)
return generar_grafico_particion_data(dataD, 'genre', title, naturalsort=True,
doprint=doprint, doplot=doplot, plotlim=plotlim)
# ------------------------------------------------------------------------------
def genera_grafico_genero_popularidad(data, tramos, title='Distribución géneros por tramo de popularidad\n',
colorbar=False, fz=10, normalize=True, porcentaje='popularidad'):
"""
Grafica la distribución de géneros en cada uno de los tramos de popularidad.
"""
tramoLabel = []
# Obtiene los generos
g = data['genre'].unique()
ng = len(g)
nu = len(tramos)
# Genera la matriz
mat = np.zeros((ng, nu))
for i in range(len(tramos)):
tramoLabel.append('{0}-{1}'.format(*tramos[i]))
d = genera_grafico_genero_tramo_popularidad(data, tramos[i][0], tramos[i][1], doprint=False, doplot=False)
gi = d['genre']
ui = d['total']
for j in range(len(gi)):
# Busca el genero (fila)
for k in range(len(g)):
if g[k] == gi[j]:
mat[k, i] = ui[j]
break
if porcentaje=='none':
pass
elif porcentaje=='popularidad':
for i in range(len(tramos)): # Suma por columna y divide
total = np.sum(mat[:, i])
if total == 0:
continue
for k in range(len(g)):
mat[k, i] = mat[k, i] / total * 100
elif porcentaje=='genero':
for i in range(len(g)): # Suma por columna y divide
total = np.sum(mat[i, :])
if total == 0:
continue
for k in range(len(tramos)):
mat[i, k] = mat[i, k] / total * 100
else:
raise Exception('Modo porcentaje incorrecto, valores posibles:none,popularidad,genero')
aspect = min(1, mat.shape[1]/mat.shape[0])
fig, ax = plt.subplots(figsize=(10,10), dpi=100)
im = ax.imshow(mat, interpolation='nearest', cmap=plt.cm.Blues, aspect=aspect) # Controlar el aspecto para el "ancho"
if colorbar:
ax.figure.colorbar(im, ax=ax)
ax.set(xticks=np.arange(mat.shape[1]),
yticks=np.arange(mat.shape[0]),
xticklabels=tramoLabel,
yticklabels=g,
ylabel='Géneros',
xlabel='Tramo popularidad')
ax.set_title(title, fontsize=20)
# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode='anchor')
# Escribe los totales en cada data
fmt = '.1f' if normalize else 'd'
thresh = mat.max() / 2.
for i in range(mat.shape[0]):
for j in range(mat.shape[1]):
if mat[i, j] != 0:
if fmt == 'd':
t = int(mat[i, j])
else:
t = format(mat[i, j], fmt)
else:
t = 0
ax.text(j, i, t, ha='center', va='center',
color='white' if mat[i, j] > thresh else 'black', fontsize=fz)
fig.tight_layout()
# ------------------------------------------------------------------------------
def run_classifier(clf, X, X_train, X_test, y, y_train, y_test, num_tests=100, cv=5, crossValidation=False):
"""
Corre un set de clasificadores. Corre cross-validation para obtener accuracy, recall y precisiton.
"""
metrics = {'f1-score': [], 'precision': [], 'recall': [], 'accuracy': []}
tini = time.time()
# Entrena
for _ in range(num_tests):
clf.fit(X_train, y_train) # Entrenamos con X_train y clases y_train
y_pred = clf.predict(X_test) # Predecimos con nuevos datos (los de test X_test)
metrics['y_pred'] = y_pred
metrics['y_prob'] = clf.predict_proba(X_test)[:, 1]
metrics['accuracy'].append(accuracy_score(y_test, y_pred, normalize=True))
metrics['f1-score'].append(f1_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred)))
metrics['recall'].append(recall_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred)))
metrics['precision'].append(precision_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred)))
metrics['accuracy-mean'] = np.array(metrics['accuracy']).mean()
metrics['f1-score-mean'] = np.array(metrics['f1-score']).mean()
metrics['precision-mean'] = np.array(metrics['precision']).mean()
metrics['recall-mean'] = np.array(metrics['recall']).mean()
metrics['time'] = time.time() - tini
# Calcula cross validation
if crossValidation:
scoring = ['precision_macro', 'recall_macro', 'accuracy', 'f1_macro']
cv_results = cross_validate(clf, X, y, cv=cv, scoring=scoring, return_train_score=True)
metrics['cv-precision'] = cv_results['test_precision_macro']
metrics['cv-recall'] = cv_results['test_recall_macro']
metrics['cv-f1-score'] = cv_results['test_f1_macro']
metrics['cv-accuracy'] = cv_results['test_accuracy']
# Calcula el promedio
metrics['cv-precision-mean'] = np.mean(metrics['cv-precision'])
metrics['cv-recall-mean'] = np.mean(metrics['cv-recall'])
metrics['cv-f1-score-mean'] = np.mean(metrics['cv-f1-score'])
metrics['cv-accuracy-mean'] = np.mean(metrics['cv-accuracy'])
else:
metrics['cv-precision-mean'] = metrics['precision-mean']
metrics['cv-recall-mean'] = metrics['recall-mean']
metrics['cv-f1-score-mean'] = metrics['f1-score-mean']
metrics['cv-accuracy-mean'] = metrics['accuracy-mean']
return metrics
# ------------------------------------------------------------------------------
def crear_matriz_confusion(y_true, y_pred, normalize=False, title=None,
cmap=plt.cm.Blues, model_name='', fz=10, modoPorcentaje=False,
figsize=10, dpi=100):
"""
Esta función crea la matriz de confusión a partir de una clasificación.
"""
# Calcula la matriz de confusión
cm = confusion_matrix(y_true, y_pred)
# Modo porcentaje
if modoPorcentaje:
cm2 = np.zeros((len(cm),len(cm)))
for i in range(len(cm)):
s = 0
for j in range(len(cm)):
s += cm[i][j]
s = float(s)
if s == 0: # Si nada se predijo continua
continue
for j in range(len(cm)):
cm2[i,j]=float(1.0* cm[i][j])/s * 100
cm = cm2
# Sólo se usan las etiquetas de las clases
classes = unique_labels(y_true, y_pred)
fig, ax = plt.subplots(figsize=(figsize,figsize), dpi=dpi)
im = ax.imshow(cm, interpolation='nearest', cmap=cmap)
ax.figure.colorbar(im, ax=ax)
# We want to show all ticks*...
ax.set(xticks=np.arange(cm.shape[1]),
yticks=np.arange(cm.shape[0]),
# ... and label them with the respective list entries
xticklabels=classes, yticklabels=classes,
ylabel='Clase verdadera',
xlabel='Clase predicha')
if title is not None:
ax.set_title('{0} - {1}\n'.format(title, model_name), fontsize=20)
# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode='anchor')
# Escribe los totales en cada data
fmt = '2f' if normalize else 'd'
# Si muestra porcentajes
if modoPorcentaje:
fmt = '.1f'
thresh = cm.max() / 2.
for i in range(cm.shape[0]):
for j in range(cm.shape[1]):
d = cm[i, j]
if modoPorcentaje:
if d < 0.1:
d = 0
if d != 0:
d = format(d, fmt)
ax.text(j, i, d, ha='center', va='center',
color='white' if cm[i, j] > thresh else 'black', fontsize=fz)
fig.tight_layout()
return ax
# ------------------------------------------------------------------------------
def grafico_confusion_genero(y_true, y_pred, genre, model_name='', cutPor=1, showOther=True):
"""
Crea un gráfico de la confusión por género (torta).
"""
# Calcula la matriz de confusión
cm = confusion_matrix(y_true, y_pred)
# Sólo se usan las etiquetas de las clases
classes = unique_labels(y_true, y_pred)
# Busca el genero
j = -1
for i in range(len(classes)):
if classes[i].lower() == genre.lower():
j = i
break
if j == -1:
raise Exception('Genero {0} no existe'.format(genre))
title = r"$\bf{" + r'Género\ '+ genre + "}$" + ' - {0}\n'.format(model_name)
# Corta los resultados a un determinado porcentaje
dt = cm[j, :] # Datos
dt2 = [] # Datos con porcentaje filtrado
g = [] # Géneros filtrados
s = np.sum(dt)
explode = []
ignored = 0
for i in range(len(dt)):
por = dt[i]/s # Porcentaje
if por >= cutPor/100:
dt2.append(dt[i])
if i == j:
g.append(r'$\bf{'+ classes[i] +'}$')
explode.append(0.15)
else:
g.append(classes[i])
explode.append(0)
else:
ignored += dt[i]
if showOther:
dt2.append(ignored)
g.append(r'$\it{Otros}$')
explode.append(0.05)
# Creamos el gráfico
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
ax.pie(dt2, labels=g, explode=explode, autopct='%.1f%%', shadow=False,
startangle=90, pctdistance=0.75)
ax.set_title(title, fontsize=12)
ax.set_aspect(1)
_ = ax.axis('equal')
# ------------------------------------------------------------------------------
def generar_grafico_kmeans(dataset, dataWgenre, k_means, col_i, col_j, cluster=[],
genre=[], grid=True, plotKmean=True, plotKcenter=True,
kSize=3, cSize=20, cWidth=0.5, gSize=7, plotPos=None, fig=None,
title='KMEANS', fontsize=10, ticksize=10, plotStyle='.', kWidth=0.5):
"""
Grafica el resultado de kmeans para un determinado dataset.
"""
# Obtiene nombre de columnas
colname = dataset.columns
if col_i == col_j:
raise Exception('La columna no puede ser la misma')
if col_i < 0 or col_j < 0:
raise Exception('ID columna debe ser >=0')
if col_i >= len(colname) or col_j >= len(colname):
raise Exception('Se excede el número de columnas')
if plotPos is None:
print('Graficando clusters KMEANS, columnas {0}x{1} ({2} x {3})'.format(col_i, col_j, colname[col_i], colname[col_j]))
# Obtiene el total de clusters
k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
k_means_labels = pairwise_distances_argmin(dataset, k_means_cluster_centers)
n_clusters = len(k_means_cluster_centers)
if title is not None:
title = '{0} - {1} clusters'.format(title, n_clusters)
# Genera lista de colores
def htmlcolor(r, g, b, alpha):
def _chkarg(a):
if isinstance(a, int):
if a < 0:
a = 0
elif a > 255:
a = 255
elif isinstance(a, float):
if a < 0.0:
a = 0
elif a > 1.0:
a = 255
else:
a = int(round(a*255))
else:
raise ValueError('Color invalido')
return a
r = _chkarg(r)
g = _chkarg(g)
b = _chkarg(b)
return '#{:02x}{:02x}{:02x}'.format(r,g,b)
def get_cmap(n, name='hsv'):
return plt.cm.get_cmap(name, n)
cmap = get_cmap(n_clusters + len(genre) + 1)
# Colores para los clusters
colors = []
for i in range(n_clusters):
colors.append(htmlcolor(*cmap(i)))
# Colores para los generos
gcolors = []
for i in range(n_clusters, n_clusters + len(genre)):
gcolors.append(htmlcolor(*cmap(i)))
# Crea figura
if plotPos is None: # Se crea una figura de forma autonoma
fig = plt.figure(figsize=(7, 7), dpi=100)
ax = plt.gca()
else:
ax = plt.subplot(*plotPos)
# ax.set_facecolor('xkcd:salmon')
# ax.set_facecolor((1.0, 0.47, 0.42))
# Si no se pasa que cluster graficar los grafica todos
if len(cluster) == 0:
cluster = [*range(1, n_clusters+1)]
else:
for i in cluster:
if i <= 0:
raise Exception('ID cluster entre 1 y {0}'.format(n_clusters))
# Grafica los kmean
if plotKmean:
for k in cluster:
k -= 1
col = colors[k]
my_members = k_means_labels == k
ax.plot(dataset.iloc[my_members, col_i], dataset.iloc[my_members, col_j], plotStyle,
markerfacecolor=col, markeredgecolor=col, markeredgewidth=kWidth, markersize=kSize, marker=plotStyle, label='_nolegend_')
plt.xlabel(colname[col_i], fontsize=fontsize)
plt.ylabel(colname[col_j], fontsize=fontsize)
if title is not None:
plt.title(title)
ax.tick_params(axis='both', which='major', labelsize=ticksize)
ax.tick_params(axis='both', which='minor', labelsize=ticksize)
if plotPos is not None:
plt.tick_params(top=False, bottom=False, left=False, right=False, labelleft=False, labelbottom=True)
# Grafica un genero en específico
glist = dataWgenre['genre'].unique()
k = 0
for i in genre:
if i not in glist:
raise Exception('Genero {0} no conocido'.format(i))
col = gcolors[k]
my_members = dataWgenre['genre'] == i
my_members = my_members.values
ax.plot(dataset.iloc[my_members, col_i], dataset.iloc[my_members, col_j], plotStyle,
markerfacecolor=col, markeredgecolor=col, marker='.', label=i, markersize=gSize)
k += 1
# Grafica los centros
if plotKcenter:
for k, col in zip(range(n_clusters), colors):
if k+1 not in cluster:
continue
my_members = k_means_labels == k
cluster_center = k_means_cluster_centers[k]
ax.plot(cluster_center[col_i], cluster_center[col_j], plotStyle, markerfacecolor=col,
markeredgecolor='k', markersize=cSize, label='_nolegend_', markeredgewidth=cWidth)
if plotPos is None:
print('Posicion centro cluster {0}: ({1},{2})'.format(k+1, cluster_center[col_i], cluster_center[col_j]))
if grid:
plt.grid()
# Carga la leyenda
if len(genre) != 0 and plotPos is None:
plt.legend(loc='upper right')
# ------------------------------------------------------------------------------
def generar_grafico_kmeans_pairs(dataset, dataWgenre, k_means, cluster=[],
genre=[], grid=True, plotKmean=True, plotKcenter=True,
kSize=1, cSize=6, plotPos=None):
"""
Llama a la función de creación de Kmeans para cada combinación de columnas del dataset
"""
# Obtiene total columnas
tc = len(dataset.columns)
# Ensamblo la figura
fig = plt.figure(figsize=(10, 10), dpi=300)
k = 0
sp = 1
for i in range(tc):
for j in range(tc):
if j <= i:
continue
generar_grafico_kmeans(dataset, dataWgenre, k_means, i, j, cluster=cluster,
genre=genre, grid=False, plotKmean=plotKmean, plotKcenter=plotKcenter,
kSize=1, cSize=0, cWidth=0.1, gSize=3, plotPos=(sp*tc, sp*tc, i*(sp*tc) + sp*(j-1) + 1), fig=fig,
title=None, fontsize=5, ticksize=5, plotStyle='.', kWidth=0)
plt.subplots_adjust(right=1.5, bottom=0.05)
fig.tight_layout()
# ------------------------------------------------------------------------------
def generar_tabla_generos(cluster):
"""
Genera una tabla de generos y porcentajes de genero en cada cluster.
"""
n_clusters = cluster['nclusters']
genrePor = cluster['genrePor']
title = '{0}\t'.format('Genero\\Cluster'.ljust(genreStringLen))
for j in range(n_clusters):
title += '{0}\t'.format(j+1)
print(title+'\n')
for i in genres:
s = '{0}\t'.format(i.ljust(genreStringLen))
for j in range(n_clusters):
s += '{0}\t'.format(round(genrePor[i][j]*100, 1))
print(s)
# ------------------------------------------------------------------------------
def generar_particion_genero_cluster(mat, title, fz=7, colorbar=True):
"""
Genera la distribución de genero por cada cluster computado.
"""
aspect = min(1, mat.shape[1]/mat.shape[0])
fig, ax = plt.subplots(figsize=(10,10), dpi=100)
im = ax.imshow(mat, interpolation='nearest', cmap=plt.cm.Blues, aspect=aspect) # Controlar el aspecto para el "ancho"
if colorbar:
ax.figure.colorbar(im, ax=ax)
clusterTicks = []
for i in range(n_clusters):
clusterTicks.append('{0}'.format(i+1))
ax.set(xticks=np.arange(mat.shape[1]),
yticks=np.arange(mat.shape[0]),
xticklabels=clusterTicks,
yticklabels=genres,
ylabel='Géneros',
xlabel='N° Cluster')
title = '{0} - {1} clusters'.format(title, mat.shape[1])
ax.set_title(title, fontsize=20)
# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode='anchor')
# Escribe los totales en cada data
normalize = True
fmt = '.1f' if normalize else 'd'
thresh = mat.max() / 2.
for i in range(mat.shape[0]):
for j in range(mat.shape[1]):
if mat[i, j] != 0:
t = format(mat[i, j], fmt)
else:
t = 0
ax.text(j, i, t, ha='center', va='center',
color='white' if mat[i, j] > thresh else 'black', fontsize=fz)
fig.tight_layout()
# Indica si usa google o no
usaGoogle = True
# Indica si se usa drive para acceder al archivo o se debe subir
usaDrive = True
if usaGoogle:
if usaDrive:
from google.colab import auth
from oauth2client.client import GoogleCredentials
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
# Descarga de archivos
auth.authenticate_user()
gauth = GoogleAuth()
gauth.credentials = GoogleCredentials.get_application_default()
drive = GoogleDrive(gauth)
link = 'https://drive.google.com/open?id=1_BGzZEDwgHT_xd-VJkwQ9p6Q4ob6nmcx' # ID de la carpeta de google drive
fluff, file_id = link.split('=')
else:
from google.colab import files
import io
uploaded = files.upload()
# Obtiene la data de google
if usaGoogle:
if usaDrive:
downloaded = drive.CreateFile({'id':file_id})
downloaded.GetContentFile('SpotifyFeatures.csv')
else:
data = pd.read_csv(io.BytesIO(uploaded['SpotifyFeatures.csv']))
data = pd.read_csv('SpotifyFeatures.csv')
# Obtiene número de columnas y de filas
data_size = {
'col': data.shape[1],
'row': data.shape[0]
}
# Imprime la data
data.head()
En una primera instancia, en vista de los atributos las características omitidas para la exploración de datos son: key, artist_name, track_name, mode y time_signature dado que no correponden a objetos numéricos y por ende, no es posible obtener métricas.
De esta forma los datos que restan son:
Esto se hace en un comienzo para tener una noción básica de los datos. Sin embargo, al momento de realizar clustering y clasificación, estás variables serán incluidas de forma numérica para ver su correlación con el resto de los atributos.
Por otra parte, se observa que hay canciones repetidas, las cuales sólo se diferencias en su género. Esto quiere decir que una canción puede estar asociada a más de un género. Debido a esto, se opta por eliminar filas repetidas (sin distinguir sobre el género) para ver cómo es la distribución de características dentro del dataset.
# Borramos columnas sin usar
for i in ['key', 'artist_name', 'track_name', 'mode', 'time_signature']:
if i in data.columns:
del data[i]
# Crea data numérica
data_num = data.iloc[:,2:data_size['col']]
data_num_prev = data_num.shape
# Elimina datos duplicados
data_num = data_num.drop_duplicates(keep='first')
data_num_post = data_num.shape
print('Se eliminaron un total de {0} datos duplicados'.format(data_num_prev[0] - data_num_post[0]))
print('La nueva data numérica tiene un tamaño de {0}x{1}'.format(*data_num_post))
data_num.head()
Con la información depurada, se realizan gráficos que muestran la relación de las diferentes características y los histogramas asociados a cada una.
# Creo un subset de los datos
data_num_subset_por = 0.05
data_num_subset = data_num.sample(frac=data_num_subset_por, replace=True, random_state=1)
print('El subset a graficar tiene un tamaño de {0}x{1} ({2}%)'.format(data_num_subset.shape[0], data_num_subset.shape[1], data_num_subset_por*100))
g = sns.pairplot(data_num_subset.iloc[:, 0:10],
diag_kind='kde',
# markers='+',
# plot_kws=dict(s=50, edgecolor='b', linewidth=1),
# diag_kws=dict(shade=True)
) # Parametro kind='reg' agrega una recta
plt.show()
Se genera la matriz de correlación para visualizar qué variables están más correlacionadas con el resto para el set de datos numéricos registrados. Si una variable posee poca correlación con el resto quiere decir que no es una métrica que entregue mucha información si se quiere hacer clasificación, clustering, u cualquier otra metodología numérica. Primero se comienza con una correlación entre los diferentes géneros (la imagen se extrae de la exploración realizada en el hito 1):
Adicionalmente se realiza una matriz de correlación sólo con los atributos técnicos de las variables. Esto, para visualizar que características técnicas se relacionan más entre sí.
crear_matriz_correlacion(data)
Tal como se visualiza en la gráfica anterior el atributo duration_ms (duración de cada canción en milisegundos) no posee una buena correlación con el resto de atributros. Con lo cual cualquier análisis se realizará sin considerar este último.
Luego, es de interés ver cómo se relacionan las características técnicas dentro de cada género musical. Para esto, se realizan la matriz de correlación expuesta anteriormente pero filtrando las canciones por género. Como ejemplo se muestran las matrices asociadas al Rock y Ópera:
crear_matriz_correlacion(data, genre='Rock')
crear_matriz_correlacion(data, genre='Opera')
Por otra parte, se opta por hacer un histograma para todos los atributos de dataset y así observar qué valores suelen tener las canciones para cada una de las características.
plt.figure(figsize=(10,10), dpi=100)
ax = plt.axes()
data_num.hist(ax=ax, bins=20, grid=False)
plt.show()
Dentro de los atributos restantes se tiene que el atributo de popularidad no corresponde a una característica técnica. En efecto, en el hito 1 se analizó qué representaba éste atributo y se llegó a la conclusión que correspondía a la popularidad que tenía la canción a la fecha que se obtuvo el dataset. Así, tal y como se observa en la tabla que se muestra a continuación, se tiene que el top 5 de las canciones más populares fueron lanzadas al mercado hace menos de un año.
Es por ello que en el hito 2 se optó por eliminar éste atributo al momento de realizar los experimentos. Sin embargo, tal y como se expondrá más adelante, puede observarse una correlación entre la popularidad y los géneros musicales.
En efecto, éste atributo se refiere a la popularidad que tiene una canción dentro de la plataforma de Spotify y no a la "fama" real que tiene, tuvo o tendrá una canción. De esta manera, el atributo nos indica el gusto actual que poseen los usuarios frente a los diferentes géneros musicales.
Tal y como se observa en la tabla anterior, los géneros más populares en la actualidad son Pop, Electrónica, Reggaeton, entre otros. Mientras que hay géneros que son menos escuchados como el caso de la Ópera. Por lo tanto, dado que el atributo, pese a no ser técnico, entrega información importante para analizar géneros, se incorpora al dataset.
A partir de la exploración de datos y observaciones realizadass anteriormente, se procede a trabajar el dataset con las siguientes modificaciones:
En esta sección se normaliza la data y se crean valores numéricos para las columnas tipo "string".
# Carga la nueva data
originData = pd.read_csv('SpotifyFeatures.csv')
# Borramos columnas sin usar
for i in ['artist_name', 'track_name', 'track_id']:
if i in originData.columns:
del originData[i]
# Se imprime la data "raw"
originData.head()
A continuación se convierten todos los datos string a números.
keyU = originData['key'].unique()
modeU = originData['mode'].unique()
timeU = originData['time_signature'].unique()
# Ordenamos por orden
keyU.sort()
modeU.sort()
timeU.sort()
print('Posibles datos de atributo key:', keyU)
print('Posibles datos de atributo mode:', modeU)
print('Posibles datos de atributo time_signature:', timeU)
keyD = {}
for i in range(len(keyU)):
keyD[keyU[i]] = i / (len(keyU)-1)
modeD = {}
for i in range(len(modeU)):
modeD[modeU[i]] = i / (len(modeU)-1)
timeD = {}
for i in range(len(timeU)):
timeD[timeU[i]] = i / (len(timeU)-1)
# Creamos un mapa, solo si no han sido renombradas
if 'key' in originData.columns:
originData['keyM'] = originData['key'].map(lambda x: keyD[x])
originData['modeM'] = originData['mode'].map(lambda x: modeD[x])
originData['time_signatureM'] = originData['time_signature'].map(lambda x: timeD[x])
# Elimina las columnas antiguas
for i in ['key', 'mode', 'time_signature']:
if i in originData.columns:
del originData[i]
# Graficamos la data
originData.head()
A continuación, se normaliza cada dato de las columnas con respecto a su máximo, esto permite obtener mejores resultados y poder realizar la clasificación.
k = 0
for i in originData.columns:
if i=='genre':
continue
# Obtienemos el maximo y el minimo absoluto
vmin = originData[i].min()
vmax = originData[i].max()
v = max(abs(vmin), abs(vmax))
originData[i] = originData[i].map(lambda x: (x-vmin)/v)
print('Columna {3}:{0}, tmin:{1}, max:{2}'.format(i, vmin/v, vmax/v, k))
k += 1
originData.head()
Una vez agregados los nuevos atributos de forma numérica, se vuelven a realizar matrices de correlación para podr discriminar si estos son relevantes para el proceso de clasificación.
crear_matriz_correlacion(originData)
Mediante prueba y error, una vez aplicados algunos algoritmos de clasificación y clustering, se decide descartar los atributos de duration_ms, keyM y modeM, debido a que empeoraban los resultados de los algoritmos. Si bien, de la matriz de correlación mostrada anteriormente se ve que estos atributos no correlacionan mucho con los demas (lo que nos dice que no introducen información reduntate al dataset), se puede deducir que empeoran los resultados del claificadores porque añaden ruido a estos. Notar que la duración ya se había descartado de la exploración inicial de datos.
originDataF = originData.copy(deep=True)
for i in ['duration_ms', 'keyM', 'modeM']:
if i in originDataF.columns:
del originDataF[i]
crear_matriz_correlacion(originDataF)
Por otro lado, del trabajo hecho en el Hito 2 se observó que existen géneros iguales y géneros desbalanceados.
En efecto se tiene que todas las canciones catalogadas como Alternative, también se clasifican como Children's Music y Blues. Además hay géneros como A Capella que tiene muy pocas canciones en comparación con el resto de los géneros.
_= generar_grafico_particion_data(originDataF, 'genre', 'Distribución de los datos por géneros\n\n')
De esta manera, se opta por eliminar tanto los géneros repetidos Anime, Blues y Children's Music, agrupándolos en un solo género Alternative como los géneros con pocas canciones (A Capella)
originDataG = originDataF.copy(deep=True)
deleteGenres = (originDataG.genre == 'Anime') | (originDataG.genre == 'Blues') | \
(originDataG.genre == "Children’s Music") | (originDataG.genre == 'A Capella')
print('Se eliminaron {0} elementos'.format(deleteGenres.sum()))
originDataG = originDataG[~deleteGenres]
_ = generar_grafico_particion_data(originDataG, 'genre', 'Distribución de los datos por géneros\n\n')
Del gráfico anterior, se observa una distribución uniforme de los géneros (en promedio cada género contiene un 4% de los datos.
Con las canciones filtradas se vuelve a obtener la matriz de correlación sobre los atributos para todas las canciones:
crear_matriz_correlacion(originDataG)
En cuanto a la distribución de los atributos, es la siguiente:
plt.figure(figsize=(10,10), dpi=100)
ax = plt.axes()
originDataG.hist(ax=ax, bins=20, grid=False)
plt.show()
Con la exploración de los datros, fue posible obtener conocimiento de cómo están distribuidos los datos y qué tendencia presentan en general y para cada género.
Luego, con el pre-procesamiento de los datos realizado se procedió a realizar experimentos. En particular se realizó clasificación y clustering. Ambos se explican a continuación.
Una vez procesados los datos se procede a obtener la parte númerica que corresponde a las características técnicas y su clasificación (género/popularidad).
originDataSamplePor = 1.0 # Fracción de los datos que se toman
if originDataSamplePor != 1:
originDataSample = originDataG.sample(frac=originDataSamplePor, replace=True, random_state=1)
else:
originDataSample = originDataG
# Genero
originDataX_genre = originDataSample.iloc[:, 1:14] # Datos numéricos
originDataY_genre = originDataSample.iloc[:, 0] # Clase (Genre)
# Popularidad
originDataSampleC = originDataSample.copy(deep=True)
del originDataSampleC['genre']
originDataX_popularity = originDataSampleC.iloc[:, 1:14]
originDataY_popularity_data = originDataSampleC.iloc[:, 0] # Popularidad
Tal y como se mencionó antes, se utilizarán clasificadores multi-etiqueta los cuales son:
Estos se caracterizan por poder clasificar elementos pueden pertenecer a múltiples clases a la vez. Del hito 2 se observó que KNN a mayor número de vecinos disminuye la precisión, por tanto se deja un total de 3 vecinos.
Adicionalmente, para corrobar que no se produce overfitting se realiza cross-validation. Sin embargo, dado el tamaño de los datos y la capacidad de procesamiento sólo es posible realizarlo sobre el clasificador "Decision Tree".
X_train, X_test, y_train, y_test = train_test_split(originDataX_genre, originDataY_genre,
test_size=0.30, stratify=originDataY_genre)
# https://scikit-learn.org/stable/modules/multiclass.html
c0 = ('Extra Tree Classifier', ExtraTreeClassifier(), True, True)
c1 = ('Decision Tree', DecisionTreeClassifier(), False, True)
c2 = ('Extra Trees Classifier', ExtraTreesClassifier(n_estimators=100), False, True)
c3 = ('Random Forest Classifier', RandomForestClassifier(n_estimators=100), False, True)
# Clasificadores tipo KNN
c4 = ('KNN-3', KNeighborsClassifier(n_neighbors=3), False, True)
c5 = ('KNN-5', KNeighborsClassifier(n_neighbors=5), False, True)
c6 = ('KNN-10', KNeighborsClassifier(n_neighbors=10), False, True)
c7 = ('KNN-25', KNeighborsClassifier(n_neighbors=25), False, True)
c8 = ('KNN-50', KNeighborsClassifier(n_neighbors=50), False, True)
c9 = ('KNN-100', KNeighborsClassifier(n_neighbors=100), False, True)
# Otros, utilizados para realizar gráficos
c10 = ('Decision Tree - Depth3', DecisionTreeClassifier(max_depth=3), False, False)
c11 = ('Extra Trees Classifier - Depth3', ExtraTreeClassifier(max_depth=3), False, False)
# Set de clasificadores y resultados
classifiers = [c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11]
results = {}
# Ejecuta cada uno de los clasificadores
print('Total data entrenamiento: {0}, testing: {1}'.format(len(y_train), len(y_test)))
k = 0
for name, clf, doCrossValidation, printResults in classifiers:
metrics = run_classifier(clf, originDataX_genre, X_train, X_test,
originDataY_genre, y_train, y_test, num_tests=1, cv=5,
crossValidation=doCrossValidation)
if printResults:
print('----------------')
if doCrossValidation:
print('Cross Validation:')
print('Resultados para clasificador ({0}): {1}'.format(k, name))
print('Tiempo de calculo: {0}s'.format(round(metrics['time'], 2)))
print('Accuracy promedio:\t', round(metrics['cv-accuracy-mean'], 4))
print('Precision promedio:\t', round(metrics['cv-precision-mean'], 4))
print('F1-score promedio:\t', round(metrics['cv-f1-score-mean'], 4))
print('Recall promedio:\t', round(metrics['cv-recall-mean'], 4))
print('----------------\n')
results[name] = metrics
k += 1
De los valores anteriores, el clasificador que arroja mejores resultados corresponde a los árboles de decisión. En particular "Random Forest" era quién entregaba los mejores resultados.
Ahora bien, es interesante visualizar el árbol de decisión que se utiliza para realizar la clasificación y así conocer qué atributos son los más importantes al momento de dividir el dataset.
Para esto, se trabaja con un árbol de baja profundidad, dado que sólo que quiere visualizar los primeros atributos.
print(results['Decision Tree'])
# Grafico Decision Tree
name, clf, _, _ = classifiers[10]
print('Arbol de decisión para {0}'.format(name))
dot_data = StringIO()
classes = unique_labels(y_test, results[name]['y_pred'])
export_graphviz(clf, out_file=dot_data, feature_names = originDataX_genre.columns,
class_names=classes, filled=True, proportion=False)
# Edito el string para eliminar los values
dot = dot_data.getvalue()
todelete = ['value']
lines = dot.split(';')
for i in range(len(lines)):
j = lines[i] # Almacena la linea
p_value = j.find('\\nvalue =')
p_class = j.find('\\nclass =')
if (p_value == -1 or p_class == -1):
continue
j = j[0:p_value] + j[p_class:len(j)]
lines[i] = j
dot = ';'.join(lines)
graph = pydotplus.graph_from_dot_data(dot)
Image(graph.create_png())
# Grafico Extra Trees Classifier - Depth3
name, clf, _, _ = classifiers[11]
print('Arbol de decisión para {0}'.format(name))
dot_data = StringIO()
classes = unique_labels(y_test, results[name]['y_pred'])
export_graphviz(clf, out_file=dot_data, feature_names = originDataX_genre.columns,
class_names=classes, filled=True, proportion=False)
# Edito el string para eliminar los values
dot = dot_data.getvalue()
todelete = ['value']
lines = dot.split(';')
for i in range(len(lines)):
j = lines[i] # Almacena la linea
p_value = j.find('\\nvalue =')
p_class = j.find('\\nclass =')
if (p_value == -1 or p_class == -1):
continue
j = j[0:p_value] + j[p_class:len(j)]
lines[i] = j
dot = ';'.join(lines)
graph = pydotplus.graph_from_dot_data(dot)
Image(graph.create_png())
De los gráficos anteriores, se tiene que los 2 atributos que comienzan la partición del dataset para la clasificación corresponden a popularidad (popularity) y a la cantidad de palabras pronunciadas (speechiness).
Lo anterior puede estar relacionado a la correlación comentada anteriormente sobre la relación que tienen los géneros con la popularidad. En cuanto al speechiness, éste atributo puede ser importante debido a que géneros como Comedy tienen un valor muy alto de palabras dichas a lo largo de la pista, a diferencia de géneros como la música electrónica cuyas canciones poseen pocas palabras.
A modo de evaluar el desempeño se grafica el tiempo y la precisión de cada clasificador:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
p = [] # Precision
t = [] # Tiempo de cálculo
n = [] # Nombre
for name, _, _, d in classifiers:
if d:
n.append(name)
t.append(results[name]['time'])
p.append(results[name]['accuracy-mean'])
ax.bar(n, p)
plt.grid()
ax.set(xticklabels=n,
ylabel='Accuracy',
xlabel='Algoritmo clasificación')
ax.set_title('Accuracy algoritmos clasificación', fontsize=12)
# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode='anchor')
# Agrega tiempo
axt = ax.twinx()
axt.plot(n, t, 'r.-')
axt.tick_params('y', colors='r')
_ = axt.set_ylabel('Tiempo cálculo (s)', color='r')
Ya ejecutados los clasificadores antes mencionados y hecho el análsis del tiempo de ejecución y precisión, se procede a determinar las matrices de confusión para observar gráficamente los resultados.
name, clf, _, _ = classifiers[0] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[1] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[2] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[3] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[4] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[8] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
name, clf, _, _ = classifiers[9] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
En base al clasificador con mejor Accuracy (Random Forest Classifier) se muestran los resultados de todos los géneros:
name, clf, _, _ = classifiers[9] # Indicar que clasificador usar
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Alternative', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Classical', model_name=name, cutPor=4)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Comedy', model_name=name, cutPor=2)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Country', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Dance', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Electronic', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Folk', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Hip-Hop', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Indie', model_name=name, cutPor=6)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Jazz', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Movie', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Opera', model_name=name, cutPor=2, showOther=True)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Pop', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'R&B', model_name=name, cutPor=7)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Rap', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Reggae', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Reggaeton', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Rock', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Ska', model_name=name, cutPor=4)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Soul', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Soundtrack', model_name=name, cutPor=5)
grafico_confusion_genero(y_test, results[name]['y_pred'], 'World', model_name=name, cutPor=5)
En base a la aplicación de los diferentes clasificadores se obtuvo que el tipo árbol Random Forest fue el que alcanzó una mayor precisión. Para los clasificadores KNN la presición aumentó desde 3 a los 100 vecinos, posteriormente no se alcanzó una mejor precisión en los resultados. En cualquier caso el clasificador KNN es el más costoso computacionalmente, tomando un tiempo mucho mayor que el resto de clasifcadores.
Para los algoritmos tipo árbol (Extra Trees y Random Forest), se tiene que ambos pertenecen a lo que se conoce como "grupos de clasificadores" (ensemble classifiers). Estos toman diferentes clasificadores que se entrenan bajo condiciones diferentes y el resultado final es una combinación de todos los resultados.
En el caso de Extra Trees, aplica el clasificador Extra tree en múltiples subconjuntos. A su vez, el clasificador Extra tree se caracteriza por generar la división de los atributos de forma aleatoria. A diferencia de un árbol de decisión que busca escoger los atributos que dividan la data de la mejor forma.
Ahora bien, una vez conocidos los resultados de cada clasificador se analiza las matrices de confusión, en particular, para los clasificadores que presentaron mayor precisión. En una primera instancia se observa que la diagonal (verdaderos positivos) es notoria en cada una de las matrices.
En cuanto a los árboles que se construyen la clasificación se reconocen atributos que logran particionar en mayor forma el dataset, los cuales corresponden a la cantidad de palabras pronunciadas y a la popularidad. Otros atributos que le siguen en la construcción de los árboles se encuentra "instrumentalness" que según la página oficial de Spotify, predice si una pista no contiene voces usando sonidos como "Ooh!" y "Aah!" como instrumentos predictivos. Notar que esto está en relación con el atributo speechiness. De esta forma, se tiene que aparte de la popularidad, el hecho de que la canción contenga palabras o voces es un parámetro inicial para la clasificación.
Por otro, los géneros que presentaron mejor precisión fueron: Comedy, Electronic, Opera, Reggaeton, Ska, Soundtrack y World, mientras que los géneros que obtvieron una menor precisión se encuentran: Rock, Pop, Indie, R&B, Rap y Hip-Hop. Interesante es el caso de estos dos últimos géneros, en donde los falsos negativos son similares entre ambos, mostrando gran similitud (similar a lo que ocurría con los géneros mencionados en el párrafo anterior).
De los gráficos circulares fue posible observar de mejor manera la similitud de los géneros en base a la clasificación multi etiqueta. De estos se obtuvieron resultados evidentes como el de Rap y Hip-Hop mecionado anteriormente y otros más interesantes como Soul que suele clasificarse como Folk y Country.
Ahora bien, en base a la relación que hay entre la popularidad actual de una canción y el género al cual pertenece, se realiza clasificación considerando como clase objetivo la popularidad.
Para lo anterior, se escoge todas las canciones y se agrupan en clases discretas de popularidad la cual está en un intervalo de 0 a 100.
# Aquí se convierte la popularidad a un espacio de clases discreto (strings)
v_min = originDataY_popularity_data.min()
v_max = originDataY_popularity_data.max()
# La popularidad se convierte a un espacio de clases discerto
def f(x, num_class=10, class_factor=100, dointeger=True, roundn=2):
itv = (v_max - v_min) / num_class # Aumenta el intervalo
j = min(num_class, max(1, int(((x - x%itv)/itv) + int(x%itv > 0)))) # Intervalo ganador
# Calcula las clases
c1 = ((j - 1)*itv + v_min)*class_factor
c2 = (j*itv + v_min)*class_factor
if dointeger:
c1 = int(c1)
c2 = int(c2)
else:
c1 = round(c1, roundn)
c2 = round(c2, roundn)
return '{0}-{1}'.format(c1, c2)
originDataY_popularity10 = originDataY_popularity_data.map(lambda x: f(x, num_class=10))
_ = generar_grafico_particion_data(originDataY_popularity10, None, 'Distribución de los datos por popularidad (10 clases)\n\n')
Antes de realizar la clasificación se evalúa qué géneros están presentes en cada uno de los tramos de popularidad y de esta manera hacer más evidente la relación género-popularidad.
genera_grafico_genero_popularidad(originDataSample, [[0,10],[10,20],[20,30],[30,40],[40,50],[50,60],[60,70],[70,80],[80,90],[90,100]], porcentaje='popularidad')
De la tabla anterior se observan géneros con alta popularidad (Poy y Hip-Hop) y otros con poca popularidad (Movie y Ópera). Esto se observa mejor en los gráficos circulares que se exponen a continuación:
_ = genera_grafico_genero_tramo_popularidad(originDataSample, 0, 10, plotlim=1)
_ = genera_grafico_genero_tramo_popularidad(originDataSample, 70, 100, plotlim=1)
Notar que hay pocas canciones en tramos de popularidad alta (70-100)% y (0-10)%, el grueso de las canciones está asociada a popularidades menores a 70. Por ello se eliminan aquellas canciones que posean dicha popularidad.
_ = data.hist(column='popularity')
originDataY_popularity_delete = (originDataY_popularity_data >= 0.7) | (originDataY_popularity_data <= 0.1)
originDataX_popularity_del = originDataX_popularity[~originDataY_popularity_delete]
originDataY_popularity_del_data = originDataY_popularity_data[~originDataY_popularity_delete]
originDataSample_del = originDataSample[~originDataY_popularity_delete]
v_min = originDataY_popularity_del_data.min()
v_max = originDataY_popularity_del_data.max()
genera_grafico_genero_popularidad(originDataSample_del, [[10,20],[20,30],[30,40],[40,50],[50,60],[60,70]], porcentaje='popularidad')
Para los datos de popularidad nuevos se realizan distintas particiones en clases discretas de popularidad según tramos.
# Actualiza los máximos
v_min = originDataY_popularity_del_data.min()
v_max = originDataY_popularity_del_data.max()
# Realiza las particiones
originDataY_popularity2 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=2))
originDataY_popularity3 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=3))
originDataY_popularity4 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=4))
originDataY_popularity6 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=6))
originDataY_popularity7 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=7))
originDataY_popularity10 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=10))
originDataY_popularity15 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=15))
originDataY_popularity20 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=20))
_ = generar_grafico_particion_data(originDataY_popularity7, None, 'Distribución de los datos por popularidad (7 clases)\n\n')
Hecho lo anterior, se realiza clasificar la popularidad de las canciones:
XDataset = originDataX_popularity_del
YDataset = originDataY_popularity7
X_train, X_test, y_train, y_test = train_test_split(XDataset, YDataset,
test_size=0.30, stratify=YDataset)
# https://scikit-learn.org/stable/modules/multiclass.html
c0 = ('Extra Tree Classifier', ExtraTreeClassifier())
c1 = ('Decision Tree', DecisionTreeClassifier())
c2 = ('Extra Trees Classifier', ExtraTreesClassifier(n_estimators=100))
c3 = ('Random Forest Classifier', RandomForestClassifier(n_estimators=100))
c4 = ('KNN-3', KNeighborsClassifier(n_neighbors=3))
classifiers = [c0, c1, c2, c3, c4]
print('Total data entrenamiento: {0}, testing: {1}'.format(len(y_train), len(y_test)))
results = {}
k = 0
for name, clf in classifiers:
metrics = run_classifier(clf, XDataset, X_train, X_test,
YDataset, y_train, y_test, num_tests=1, crossValidation=False)
print('----------------')
print('Resultados para clasificador ({0}): {1}'.format(k, name))
print('Tiempo de calculo: {0}s'.format(round(metrics['time'], 2)))
print('Precision promedio:\t', round(metrics['precision-mean'], 4))
print('Recall promedio:\t', round(metrics['recall-mean'], 4))
print('F1-score promedio:\t', round(metrics['f1-score-mean'], 4))
print('----------------\n')
results[name] = metrics
k += 1
A modo de evaluar el desempeño se grafica el tiempo y la precisión de cada clasificador:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
p = [] # Precision
t = [] # Tiempo de cálculo
n = [] # Nombre
for name, _ in classifiers:
n.append(name)
t.append(results[name]['time'])
p.append(results[name]['precision-mean'])
ax.bar(n, p)
plt.grid()
ax.set(xticklabels=n,
ylabel='Precisión',
xlabel='Algoritmo clasificación')
ax.set_title('Precisión algoritmos clasificación', fontsize=12)
# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode='anchor')
# Agrega tiempo
axt = ax.twinx()
axt.plot(n, t, 'r.-')
axt.tick_params('y', colors='r')
_ = axt.set_ylabel('Tiempo cálculo (s)', color='r')
name, clf = classifiers[3] # Indicar que clasificador usar
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)',
model_name=name, fz=6, modoPorcentaje=True, normalize=True, figsize=8)
Reconociendo la útil que puede ser predecir la popularidad que una canción puede tener en Spotify a partir de sus atributos técnicos, se tiene que los valores obtenidos en cuanto a precisisón son bajos.
En efecto, estos fueron menores al 50%, lo cual no permite predecir de forma confiable qué tan popular será una canción con una combinación determinada de características técnicas.
Una vez realizado el primer experimento que consistía a clasificación, se procede a la aplicación de algoritmos de clustering sobre los atributos técnicos.
Luego, se cruzan los resultados obtenidos del clustering con los géneros asociados a cada canción para ver si hay géneros que se agrupan dentro de un mismo cluster, o si hay clusters en que hay presencia dominante de un único género musical.
originDataSamplePor = 1.0 # Fracción de los datos que se toman
if originDataSamplePor != 1:
originDataSample = originDataG.sample(frac=originDataSamplePor, replace=True, random_state=1)
else:
originDataSample = originDataG
originDataX = originDataSample.iloc[:, 1:14] # Datos numéricos
originDataY = originDataSample.iloc[:, 0] # Clase (Genre)
genres = originDataY.unique()
genres.sort()
genreStringLen = 0
for i in range(len(genres)): # Calcula el largo del string del genero
genreStringLen = max(genreStringLen, len(genres[i]))
originDataX.head()
# Crea clustering a distintos clusters
clusters = {}
ncluster = [2, 3, 5, 10, 13, 26] # Qué clusters se analizarán
distorsiones = []
for n_clusters in ncluster:
t0 = time.time()
k_means = KMeans(n_clusters=n_clusters, random_state=n_clusters) # Crea el Kmeans
k_means.fit(originDataX)
t_batch = time.time() - t0
print('Tiempo de generación {1} clusters:\t{0}s'.format(round(t_batch, 2), n_clusters))
# Agrega la distorsión
dst = sum(np.min(cdist(originDataX, k_means.cluster_centers_, 'euclidean'),
axis=1)) / originDataX.shape[0]
distorsiones.append(dst)
# Realiza análisis por cada género
genreKmean = []
k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
k_means_labels = pairwise_distances_argmin(originDataX, k_means_cluster_centers)
# Porcentajes por cada cluster
genrePor = {}
genreMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada genero
genreClusterMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada cluster
# Obtiene los generos de cada cluster
for i in range(len(genres)):
# Vector con porcentajes
gp = [0]*(n_clusters+1) # El último tiene el total
total = 0
for k in range(n_clusters):
my_members = k_means_labels == k
clusterData = originDataSample.iloc[my_members, 0]
gp[k] = (clusterData == genres[i]).sum()
total += gp[k]
gp[len(gp) - 1] = total
# Calcula los porcentajes
for k in range(n_clusters):
genreMat[i][k] = gp[k] / total * 100
genreClusterMat[i][k] = gp[k]
genrePor[genres[i]] = gp
for k in range(n_clusters):
genrePor[genres[i]][k] /= total
# Divide por el total para el porcentaje por cluster
for i in range(n_clusters):
s = int(genreClusterMat[:, i].sum()) # Suma datos del cluster
# print('Cluster {0}, N° datos: {1}'.format(i+1, s))
if s != 0:
genreClusterMat[:, i] = genreClusterMat[:, i] / s * 100
# Guarda los resultados
clusters[n_clusters] = {'kmeans': k_means,
'time': t_batch,
'nclusters': n_clusters,
'distortion': dst,
'genrePor': genrePor,
'genreMat': genreMat,
'genreClusterMat': genreClusterMat,
'clusterCenter':k_means_cluster_centers,
'labels': k_means_labels
}
El siguiente gráfico ilustra el número de clusters realizado y el tiempo de cálculo:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
t = [] # Tiempo de cálculo
n = [] # Nombre
for i in ncluster:
t.append(clusters[i]['time'])
ax.plot(ncluster, t, '.-')
plt.grid()
ax.set(ylabel='Tiempo de cálculo (s)',
xlabel='Número de clusters')
_ = ax.set_title('Tiempo de cálculo', fontsize=12)
A continuación se grafica la distorsión en función del número de clusters y aplicando el método de Elbow (o del codo) se escoge el número óptimo de clusters:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
ax.plot(ncluster, distorsiones, 'bx-')
ax.xlabel('k')
ax.ylabel('Distorsión')
ax.title('Método de Elbow para encontrar el K óptimo')
plt.grid()
plt.show()
Del gráfico anterior se observa que con 10 clusters es posible obtener una buena aproximación al número de clusters, a partir del cual el error no baja de forma significativa.
A continuación se muestra un gráfico para un cluster y el género:
generar_grafico_kmeans(originDataX, originDataSample, clusters[10]['kmeans'], 0, 1, cluster=[2,1])
generar_grafico_kmeans(originDataX, originDataSample, clusters[10]['kmeans'], 0, 7, cluster=[1,2], genre=['Comedy'], gSize=3)
generar_grafico_kmeans_pairs(originDataX, originDataSample, clusters[10]['kmeans'], cluster=[1,2], genre=['Comedy'])
A continuación se busca calcular el porcentaje de repartición de cada género en los clusters calculados, ello es, iterar por género y ver el % de aparición en cada cluster generado.
generar_tabla_generos(clusters[10])
Luego se visualizan los resultados tanto por género como por cluster. Es decir, se analiza la distribución de las canciones en cada uno de los clúster y la distribución de las canciones de cada cluster y su distribución en cada uno de los géneros.
La finalidad de esto es ver si hay clusters puros (con un género) o que contienen géneros con características similares.
generar_particion_genero_cluster(clusters[10]['genreMat'], 'Partición género por cada cluster')
generar_particion_genero_cluster(clusters[10]['genreClusterMat'], 'Partición cluster por cada género')
En primera instancia se observa que la mayoría de los géneros se concentran en un sólo cluster (cluster 5). Sin embargo, hay géneros como Comedy cuyas canciones se agrupan en un sólo cluster (10) y a su vez dicho cluster contiene sólo canciones de dicho género. Por lo tanto se infiere que este género en particular tiene una combinación de atributos técnicos particulares que lo diferencian del resto de géneros.
Además se tienen cluster que logran agrupar géneros con atributos similares como el clsuter 2 que agrupa principalmente canciones asociadas a Ópera, Soundtrack y Classical.
Dado que casi todos los géneros están el cluster 5, se analiza éste aplicando clustering sobre el subconjunto de canciones:
k_means_labels = clusters[10]['labels']
my_members = k_means_labels == 4 # Cluster N°5
cluster5data = originDataSample[my_members]
originDataX = cluster5data.iloc[:, 1:14] # Datos numéricos
originDataY = cluster5data.iloc[:, 0] # Clase (Genre)
# Crea clustering a distintos clusters
clusters5data = {}
ncluster = [2, 3, 5, 10, 13, 26] # Qué clusters se analizarán
distorsiones = []
for n_clusters in ncluster:
t0 = time.time()
k_means = KMeans(n_clusters=n_clusters, random_state=n_clusters) # Crea el Kmeans
k_means.fit(originDataX)
t_batch = time.time() - t0
print('Tiempo de generación {1} clusters:\t{0}s'.format(round(t_batch, 2), n_clusters))
# Agrega la distorsión
dst = sum(np.min(cdist(originDataX, k_means.cluster_centers_, 'euclidean'),
axis=1)) / originDataX.shape[0]
distorsiones.append(dst)
# Realiza análisis por cada género
genreKmean = []
k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
k_means_labels = pairwise_distances_argmin(originDataX, k_means_cluster_centers)
# Porcentajes por cada cluster
genrePor = {}
genreMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada genero
genreClusterMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada cluster
# Obtiene los generos de cada cluster
for i in range(len(genres)):
# Vector con porcentajes
gp = [0]*(n_clusters+1) # El último tiene el total
total = 0
for k in range(n_clusters):
my_members = k_means_labels == k
clusterData = cluster5data.iloc[my_members, 0]
gp[k] = (clusterData == genres[i]).sum()
total += gp[k]
gp[len(gp) - 1] = total
genrePor[genres[i]] = gp
if total != 0:
# Calcula los porcentajes
for k in range(n_clusters):
genreMat[i][k] = gp[k] / total * 100
genreClusterMat[i][k] = gp[k]
for k in range(n_clusters):
genrePor[genres[i]][k] /= total
# Divide por el total para el porcentaje por cluster
for i in range(n_clusters):
s = int(genreClusterMat[:, i].sum()) # Suma datos del cluster
# print('Cluster {0}, N° datos: {1}'.format(i+1, s))
if s != 0:
genreClusterMat[:, i] = genreClusterMat[:, i] / s * 100
# Guarda los resultados
clusters5data[n_clusters] = {'kmeans': k_means,
'time': t_batch,
'nclusters': n_clusters,
'distortion': dst,
'genrePor': genrePor,
'genreMat': genreMat,
'genreClusterMat': genreClusterMat,
'clusterCenter':k_means_cluster_centers,
'labels': k_means_labels
}
generar_particion_genero_cluster(clusters5data[10]['genreMat'], 'Partición género por cada cluster')
Pese a que se tiene una mejor distribución de los géneros en los diferentes clusters, no es posible afirmar que un cluster esté asociado a uno o más clusters de forma significativa.
Lo anterior tiene relación con las múltiples variaciones presentes en cada género (sub-géneros). Esto genera que una canción pueda estar asociada a más de un género o simplemente, que una canción no pueda encasillarse en los géneros que componen el dataset.
De los experimentos realizados tanto en clasificación como en clustering, se obtiene en una primera instancia que los árboles de decisión fueron los clasificadores que presentaron una mayor precisión al estimar géneros multi-etiqueta. Tanto Random Forest como Extra Trees dieron los mejores resultados. Lo anterior valida en parte la hipótesis inicial, la cual establece que los géneros pueden quedar definidos en base a sólo sus características técnicas.
Ahora bien, se reconoce el atributo de popularidad pese a no ser una métrica técnica u objetiva, tiene una relación importante con el género al cual pertenece una canción. Cabe destacar que éste atributo es un parámetro que indirectamente indica los gustos musicales de la sociedad contemporánea. En efecto, se observa que géneros como Rap y Reggaeton son más populares que Opera o la música Clásica. Lo anterior tiene componentes culturales e históricas que escapan al alcance de éste trabajo. Sin embargo que abre una posible línea de investigación que busque analizar cómo la música ha evolucionado en paralelo con la cultura dominante (o de masas) a lo largo de la tiempo. Se debe mencionar que este atributo contribuye demasiado a una mejora de los resultados de los algoritmos de clasificación, debido a que como se mencionó en el Hito 1, este no correlaciona con casi ningún atributo técnico y tiene mucha incidencia en el género de la canción.
En cuanto a Clustering, los resultados permitieron observar que hay géneros muy marcados como Comedy o Soundtrack que quedaban concentrados en un único cluster. Sin embargo, se tiene un cluster que concentra canciones de muchos géneros musicales. Esto deja entrever la similitud que poseen ciertos géneros en base a sus características, como es el caso del Rock que como género musical tiene muchas variaciones (subgéneros). Además, dada esta agrupación entre los géneros se reconoce que una canción puede asociarse a más de un género musical.
Para la clasificación, fue posible reconocer que los atributos que más peso tienen para la partición de los datos (en los árboles de decisión) son aquellos asociados a la presencia y cantidad de voces o palabras que contiene una pista. También la popularidad es determinante dentro del proceso de clasificación. Luego, si se analiza esto con lo obtenido en el proceso de clustering se tiene que géneros como Comedy y Opera son géneros con alto contenido de voces y bajo nivel de popularidad. Es por ello que lograron diferenciarse de forma notoria del resto de los géneros al visualizar la asignación de los canciones a cada cluster.
Por último, de los gráficos circulares se analizan las similitudes que tienen los géneros luego de haber aplicado el proceso de clasificación. Con esto, se tiene en una primera instancia que los géneros sí poseen similitudes, las cuales son a nivel de atributos técnicos. Además al utilizar clasificadores multi etiqueta se evidencia que los géneros son clasificables en base a estos atributos. Lo anterior es una conclusión importante si se considera que para clasificar no se toma en cuenta la letra de la canción o el artista.
Unas posibles mejoras al trabajo ya realizado, seria considerar estudiar las métricas de evaluación que son especialmente diseñadas para clasificadores multi-etiquetas, para poder evaluar mejor el comportamiento de los distintos algoritmos que se aplicaron. Por otro lado, se podría estudiar más detenidamente una reducción de dimensionalidad tanto para clasificar como para realizar clastering, todo esto en busca de mejorar los resultados previamente obtenidos ya sea eliminando ruido de los datos o información redundante.